接下來本篇文章,咱們要來說明所謂的『 I/O 』模型。
這個東西我當初看到也有點不太能理解,為什麼需要它,但後來理解以後發覺,你只要知道一個 http 請求 web server 是如何處理的,從 0 至 1,那這樣的話當你完全通了,就知道這為什麼會有這些模式。
本篇文章共分以下幾個章節 :
~ 重要備註 ~
相信有不少人聽過阻塞、非阻塞、同步、非同步,也相信有些熟悉 linux 的友人,聽過它所提供的一些上述名詞的方法,但這裡要先說明,接下來的說有上述詞語,都是指『 應用層 』的表達,而不是『 業系統層 』的所提供的方法,除非有特別說明才是作業系統的。
一個 I/O 請求進來要如何處理呢 ?
咱們這裡以 http server 處理請求時來當範例,如下圖所示,咱們以常見的 PHP + Apache 來看。如下圖 1 所示,每一個 http 請求都需要開啟一個或是使用一個進程來進行處理,這裡不是只有 http 請求會被阻塞,而是所有的 I/O ( ex. 網路請求、檔案讀取 ) 操作正常來說都是會被阻塞的。
圖 1 : 傳統 http server 的請求處理
基本上傳統的這個需要開多個進程或線程來處理 I/O 請求的會有以下幾個問題 :
主要會爆的原因有兩個,首先第一個是進程與線程的數量限制,而第二個是這樣會頻繁的進行進程間的上下文切換,這樣當併發高時,的機器基本上一定會吃不住。
圖 2 : 請求太多炸掉圖
花了那麼多的記憶體來建立那麼多個進程,但是它大部份的時間都是在等待 I/O 資料。
圖 3 : 系統資源浪費圖
那為什麼每個請求 ( I/O ) 都需要開啟一個進程或線程來處理呢 ?
咱們來看看 http server 它處理請求的運行過程會是如何 ?
它的流程如下圖 4 所示 :
圖 4 : 請求過程
其中問題就出在 2 這個步驟。
data = read(sockfd); // 這裡會阻塞整個 process
handler(data);
咱們根據前幾篇的文章知道,所謂的 I/O 過程如下圖
圖 5 : I/O 操作流程
而其中問題就出在內核緩衝區這。以圖 5 範例來看,process A 執行 read 後一開始會看到緩衝區內空空如也,那這裡你覺得它可以做什麼呢 ? 就是只能等待,而這樣就是咱們所謂的『 阻塞 』。
接下來有資料寫到緩衝區後,作業系統會告訴 process A 你觀注的緩衝區有資料囉,可以起來工作了,那這時作業系統就會進行上下文切換的跳回 A 來處理。
這也是為什麼需要開啟每個請求都需要一個進程來處理。
~ 小備註 1 ~
上述有用到的幾個 system call。
~ 小備註 2 ~
每一個 http 請求基本上就是會建立一條 socket,而 sockfd 又代表此 socket 的 file descriptor,不熟悉網路的友人可以用上面這幾個關鍵字來查詢。
由傳統使用多線程或多進程來處理 I/O 有以下兩種缺點 :
因此後來就有人提出了 :
非阻塞 I/O 模式 Reactor
~ 小提醒 ~
這裡的非阻塞 I/O 是指讓應用層的 I/O 操作,不會阻塞住進程。
reactor 模式可以幫助我們建立非阻塞 I/O 模式,也就是不需要開多個 thread 或 process 來處理 I/O 操作。
它的概念如下圖 6 所示 :
圖 6 : reactor 概念圖
上圖架構中,最重要的就是 I/O 多路復用 ( Multiplexing ) 這部份,基本上它是作業系統所提供的功能。多路復用可以幫助我們監控所有有註冊的 socket,當它有事件 ( ex. 可讀資料、有連線時 ) 進來以後,就會由指定的 handler ( callback ) 來進行處理。
下面為 reactor 模式的概念碼。而事實上這就是 nodejs 中我們常聽到的 event loop 的機制。
但這裡要注意,epoll_wait 本身是一個阻塞方法,也就是說執行它時,整個 process 會被卡住。有寫過 nodejs 的人在這裡應該會有疑問,等等會解答。
while(true){
events = epoll_wait(); // 這裡會取得到 I/O 事件
for (int i=0; i < events.size(); i++){
handler(events[i]);
}
}
~ 小備註 ~ :
I/O 多路復用 ( Multiplexing ) 不同的平台有不同的實作,epoll ( linux )、kqueue ( Mac )、IOCP ( Window )。
在開始說流程直接,咱們先來看首 I/O 多路復用實際上提供的方法有那些,這裡我們以 linux 的 epoll 為範例。
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
linux_epoll_create
linux_epoll_ctl
linux_epoll_wait
下面為我們使用它的範例碼,這個範例的功用就是讓 epoll 監聽你有註冊的 socket,然後當有事件產生時,就可以從 epoll_wait 取得相對應的 socket 與事件,最後再將此事件執行到對應的 event handler。
struct events[10];
// 建立一個 epoll 用 file descriptor
epollfd = epoll_create();
// 註冊讓 epoll 監聽某 socket 的 EPOLLIN 事件(可讀取)
epoll_ctl(epollfd, EPOLL_CTL_ADD, socket, EPOLLIN);
while(true){
// 如果 epoll queue 中有事件產生,則會回傳產生事件的 socket 與 events。
have_events_fds = epoll_wait( epollfd, events, MAX_EVENTS, -1 );
// 讀取每個有產生事件的 socket,並執行對應的 eventHandler。
for(int i=0; i < have_events_fds; i++){
eventHandler(events[n]);
}
}
這裡既然提到 epoll 後,咱們順到來看看它的兩個模式『 LT 』與 『 ET 』
上述章節咱們有提到,所謂的『 可以 』讀取資料是指內核緩衝區中有資料後,就可以讀,然後拉到 epoll 這種監聽可不可以讀的場景它多了兩個方案 :
在實際咱們在用時預設為 LT 模式。雖然 ET 模式性能較好,但因為 ET 模式可能會漏事件,例如用戶收到 100 個資料,而這時用戶只讀 50 個資料後就不讀,那就沒有下次機會了,因為它只會觸發一次。
答案是都可以。
像 nodejs 就是屬於 multiplexing 與 handler 都是在同一個 process 運行,而 php swoole 則是屬於 multiplexing 與 handler 在不同 process 運行。
在 refactor 中比較常見的組合有以下幾種 :
嚴格來說 nodejs 算是屬於這一種,不過它比較特別一點,因為它的 reactor 進程 同時包含一些 handler 工作。
在 nodejs 中它的工作分配如下 :
簡單說一下 filesystem 無法丟 epoll 原因在於,epoll 不支援。
EPERM The target file fd does not support epoll.
在 php 有名的 swoole 與 java NIO 或 netty 基本上就是屬於此種類型,下圖是 php swoole 的架構。
圖 7 : php swooole 架構
上面簡單的帶過一下,詳細 swoole 可以參考筆者這篇文章。
PHP 的 Web 運行原理 ( 4 ) - Reactor 的實現之 Swoole
基本上這種的優勢在於 :
嗯之前我也有這個疑問。
當初我的想法是如下概念碼一樣,它會在 epoll_wait 阻塞住整個 process 來監聽,然後在有事件時,丟給某個 thread 或其它 process 的 handler 來處理。
while(true){
events = epoll_wait(); // 它會一直停在這裡,等某個 socket 有事件進來。
for (int i=0; i < socket.size(); i++){
handler(events[i])
}
}
epoll_wait 的確是阻塞 I/O 操作沒錯,但是它事實上有提供一個參數 timeout,也就是如果這段時間沒有資料,它就會回傳個 null 或啥 -1 的,反正就是和你說沒資料進來,而這時你就可以繼續往下處理。
備註一下,如果想知道 libvu 所提供的 timeout 計算方式可以拉到最下面參考看看。
while(true){
events = epoll_wait(timeout); // 注意 timeout 。
for (int i=0; i < sockets.size(); i++){
handler(sockets[i])
}
// 取出接下來 event queue 中要處理的工作。
worker = queue.poll();
handler(worker);
}
~ 小備註 ~
這裡可能有人會提出,nodejs 實際上是另外開 thread 來處理運算工作,然後 event loop process 就是一直在只在做 event loop,但如果真的是這樣你想想,為什麼在 callback 中寫個 while true 所有的工作都無法做了呢 ?
nodejs 的確有偷偷的開 thread 來處理某些工作,但是它只有一些特殊的工作才需要使用 libuv 開的 worker thread 來處理。
詳細的內容可以看看筆者這篇文章。
本篇文章中我們說到以下幾個重點 :
大量 I/O 操作的情況,會導致傳統的 I/O 處理模式系統爆掉。
重點就是『 I/O 多路復用 』有了它我們才能一個 process 監控多個 socket。
在 reactor 模式的系統下,不要執行大量 cpu 運算的工作,會導致整個 process 卡住,大量 cpu 運算請另開 process 或 thread 來處理。
這裡提一下,在現今軟體世界的語言中,就我所知道的只有 nodejs ( 雖然它不算語言 ) 與 golang 這兩種語言有原生的支援非阻塞 I/O 操作,也就是說你直接使用這個語言所提供的一些 I/O 方法,它們基本上都不會阻塞住整個 process。
但是其它語言就不一定了,例如 php,它是使用一些非阻塞 I/O 的框架,而且重點要使用這框架所提供的非阻塞 I/O 方法,才能變成非阻塞 I/O 系統,詳細 php 的問題可以看看筆者寫的這一篇文章,裡面的『 Swoole 的實際使用注意 』那可以注意一下。
PHP 的 Web 運行原理 ( 4 ) - Reactor 的實現之 Swoole
最後這章節給個三個建議 :
下面為 libuv 的 timeout 計算的方法,
int uv_backend_timeout(const uv_loop_t* loop) {
if (loop->stop_flag != 0)
return 0;
if (!uv__has_active_handles(loop) && !uv__has_active_reqs(loop))
return 0;
if (!QUEUE_EMPTY(&loop->idle_handles))
return 0;
if (!QUEUE_EMPTY(&loop->pending_queue))
return 0;
if (loop->closing_handles)
return 0;
return uv__next_timeout(loop);
}
而下面為 uv__next_timeout 的源始碼。
int uv__next_timeout(const uv_loop_t* loop) {
const struct heap_node* heap_node;
const uv_timer_t* handle;
uint64_t diff;
heap_node = heap_min(timer_heap(loop));
if (heap_node == NULL)
return -1; /* block indefinitely */
handle = container_of(heap_node, uv_timer_t, heap_node);
if (handle->timeout <= loop->time)
return 0;
diff = handle->timeout - loop->time;
if (diff > INT_MAX)
diff = INT_MAX;
return (int) diff;
}
您好馬克我想請問,多路復用這樣看起來就是把傳統read socket FD的部分改成epoll_ctl 跟epoll_wait 來處理。
跟是不是用thread/process好像沒關係
同步阻塞I/O以及非同步阻塞I/O
假設有A thread 在執行 read socket 的時候會卡住,等 socket 的值吐光並且丟到A' thread處理。(必須等socket把東西吐光才能接收另外一個Socket)
異步阻塞I/O
假設有B thread 直接使用epoll_ctl 跟epoll_wait,但B thread 就站在那邊看 epoll 所觀測的socket有沒有資料(差別在於透過epoll 一次可以觀測多個socket),如果有資料就馬上處理,感覺跟同步阻塞I/O以及非同步阻塞I/O
一樣....(必須等socket把東西吐光才能接收另外一個Socket)
如果是Reactor架構
應該是啟動一個Threa專門就站在那邊看 epoll 所觀測的socket有沒有資料(差別在於透過epoll 一次可以觀測多個socket),如果有資料就馬上開一個Thread處理。(必須等socket把東西吐光才能接收另外一個Socket)